組込み現場の「C++」プログラミング 明日から使える徹底入門

高木 信尚(株式会社クローバーフィールド

5.4 サイズ効率を改善するには?

C++は,処理速度のオーバーヘッドに関しては最小限に抑える工夫がされており,処理系にもよりますが,例外が送出された場合を除けばそれほど気にする必要はないかと思います.しかし,何の工夫もなくプログラムを作成すると,どうしてもサイズが肥大化してしまいがちです.ここでは,できるかぎりサイズを小さくするために工夫すべきポイントを紹介してみたいと思います.

そのポイントは,以下のようになります.

  • 例外指定を活用する.
  • 例外を送出しない関数をインライン関数にする.
  • 必要のないデストラクタは定義しない.
  • 多数の仮想関数を持つクラスは作らない.
  • 継承の階層は浅くする.
  • テンプレートを有効活用する.

それでは,個々のポイントについて詳しく見ていきましょう.

5.4.1 例外指定を活用する

このポイントは,実はかなり処理系に依存します.例外指定の実現方法が大きく分けて2種類あるからです.すなわち,throw()を使って関数から例外が送出しないことを指定した場合,例外指定に反して送出しようとした場合の処理を行うコードを,呼び出された関数側に挿入するのか,関数を呼び出した側に挿入するのかということです.

void func() throw()
{
    throw X;
}

上記のように,例外指定throw()が付加されているにもかかわらず,内部で例外を送出する関数funcを考えてみましょう.この関数は,次のいずれかの方法で実現されることになります.

【実現方法1 ― 関数の内部で不正な例外を処理してしまう】

void func()
{
    try
    {
        throw X;
    }
    catch (...)
    {
        std::unexpected();
    }
}

【実現方法2 ― 関数の呼び出し側で不正な例外を処理する】

void func()
{
    throw X;
}
int main()
{
    try
    {
        func();
    }
    catch (...)
    {
        std::unexpected();
    }
    return 0;
}

多くの処理系では,前者の呼び出された側に埋め込む方式をとっているようですので,ここではそうであることを想定して説明することにします.もし,処理系が後者の方式をとっている場合は逆効果になる可能性があるので,使用されている処理系がどちらの方式なのか,あらかじめ調べておく必要があります.また,処理系によっては,ここで紹介するテクニックを用いても,必ずしも効果がない場合もあるので,それについても,あらかじめ小さなコード片をコンパイルしてみて,効果の有無を確認しておいてください.

関数に例外指定がない,または何らかの例外が送出される可能性があることを指定していた場合,その関数を呼び出す側では,自動記憶域期間を持つオブジェクトのデストラクタを呼び出すためのコードをコンパイラが挿入します.この暗黙的に挿入されるコードは決して小さいとはいえず,また,プログラム全体にわたって同じことが行われるため,非常にプログラムサイズを増大させてしまいます.これを回避するには,関数にthrow()という例外指定を設け,その関数が決して例外を送出しないことを表明してください.こうすることで,例外が送出された場合にデストラクタを呼び出すコードが挿入されなくなります(「3.10.2 デストラクタを持つ自動オブジェクト」参照).特に,Cで実装された資産を活用する場合には,各関数の宣言に例外指定を付けることで,かなりの効果が見込めます.

5.4.2 例外を送出しない関数をインライン関数にする

関数をインライン関数にするかどうかは,関数本体のサイズと,引数や返却値のコピーやサブルーチンの呼び出しにかかるコストだけで決まるものではありません.インライン関数では,関数の枠組みを超えた最適化を期待することができます.そのため,場合によっては関数の処理が静的に解決され,単なる定数に展開される場合もありえます.単純な計算を行う関数などは,このような最適化が行われる可能性が高いので,ある程度処理系の癖をつかんでおくとよいでしょう.

inline int foo(int a, int b)
{
    return a + b;
}
int main()
{
    printf("%d\n", foo(2, 3));
    return 0;
}

上記のように,インライン関数fooを2および3という定数式で呼び出した場合,処理系によっては,最適化の結果,次のようになることが期待できるのです.

int main()
{
    printf("%d\n", 5);
    return 0;
}

また,インライン関数の場合,その関数から決して例外が送出されないのであれば,そのことをコンパイラが知ることができます.そのため,例外指定がない場合でも,例外が送出された場合にデストラクタを呼び出すためのコードが挿入されることを抑止できることがあります.

先ほどのfoo関数から例外が送出されないことは明らかですから,例外指定throw()を記述していなくても,例外に備えるためのコードが必要ないことをコンパイラは知ることができます.

もちろん,あまりにも大きい関数をインライン関数にしてしまうと,上記のことを考慮したとしても,やはりプログラムサイズが増えてしまうので,どの程度の大きさの関数をインライン関数にするかは,ある程度実測してみて判断する必要があります.

なお,インライン関数に関しては,例外指定を付けると無条件にインライン置換されなくなる処理系もあります.インライン関数から決して例外が送出されないのであれば,例外指定がなくてもコンパイラはそのことがわかりますから,インライン関数には例外指定を付けるべきではありません.

5.4.3 必要のないデストラクタは定義しない

関数から例外が送出されたときに大きなコードをコンパイラが挿入するのは,呼び出すべきデストラクタがあるからです.クラスにデストラクタが存在しなければ,そのようなコードを挿入する必要はなくなります.

ただし,オブジェクトの解体時に呼び出される処理は,明示的に定義されたデストラクタだけではありません.そのクラスが,デストラクタを持つクラスのデータメンバーを持つ場合や,仮想関数を持つ場合,仮想継承している場合には,明示的なデストラクタがなくても何らかの解体処理が必要になることでしょう.プログラムのサイズを小さくするには,そのようなクラスも可能なかぎり避けるべきです.

5.4.4 多くの仮想関数を持つクラスは作らない

多くの仮想関数を持つクラスには,プログラム全体を通して一度も呼ばれない仮想関数が含まれている場合が多々あります.仮想関数は,実際に呼び出されるかどうかにかかわらず,すべてがリンクされてしまいます.可能であれば,ある程度クラスを分割したほうがよいかもしれません.ただし,仮想関数テーブルごとに実行時型識別情報が埋め込まれるので,細かく分割しすぎるのはかえって逆効果です.

また,仮想関数をテンプレートに置き換えるなど,他の方法にできる可能性がないか,検討してみるのもよいでしょう.

5.4.5 継承の階層は浅くする

何段階にも継承を重ねて定義されたクラスは,全階層のクラスが持つ全仮想関数がリンクされてしまいます.このような深い階層を持つクラスの場合,実際に呼び出されることがない仮想関数が多く含まれているはずです.継承の階層をできるかぎり浅くすることで,仮想関数テーブルの数もそれだけ少なくなり,リンクされる不要な仮想関数や実行時方識別情報を最小限に食い止めることができるようになります.

5.4.6 テンプレートを有効活用する

「2.4.2 テンプレートによる静的な多相性」でも説明しましたが,テンプレートを用いることで仮想関数を使わずに静的な多相性を実現することができます.また,テンプレートは実際にソースコード上で使用されたものだけが実体化されるため,無駄な関数がリンクされることがありません.

以上のように,プログラムサイズを増大させる原因は,大多数が例外処理に関するコードと仮想関数にあります.これらをいかに削減するかが,サイズ効率を改善するうえで最も重要な課題となります.